在 Figma 中將設計元素整理成系統與元件後,現在我們要進入更關鍵的一步:將這些視覺藍圖化為實際的 Flutter 程式碼!這不只是將設計稿變成 App 畫面,更是透過程式碼的「元件化」,讓未來的開發工作更有效率、程式碼更乾淨、也更易於維護。
在開始撰寫程式碼之前,先建立一個清晰的資料夾結構,能避免所有程式碼都擠在 main.dart 中,不僅有助於維護,也讓專案架構更有條理,以下是此範例將使用的資料夾架構:
lib/
├── main.dart # App 進入點,負責載入主題與啟動整個應用程式
│
├── components/ # 可重複使用的 UI 元件
│ ├── buttons/ # 按鈕元件集合
│ │ ├── primary_button.dart # 主要按鈕樣式(品牌色)
│ │ └── selection_button.dart # 輕量化的 Chip 樣式按鈕
│ │
│ ├── inputs/ # 輸入相關元件
│ │ └── text_field.dart # 自訂輸入框
│ │
│ ├── navs/ # 導覽列相關元件
│ │ └── app_bar.dart # 自訂 AppBar 樣式
│
└── theme/ # 設計系統相關設定
├── app_colors.dart # 定義應用程式的顏色系統
├── app_typography.dart # 定義字體、字重、字級等文字樣式
├── app_spacing.dart # 定義間距單位(如 4、8、16…)
├── app_radius.dart # 定義圓角大小,確保元件邊角一致
└── app_theme.dart # 將以上設定組合成 ThemeData,提供全局使用
元件化的核心思想是「單一職責」,也就是讓每個 Widget 都專注於一個特定的功能或視覺元素。這樣做的好處非常多:
在設計與實作元件時,有幾個重要的點需要考量:
onPressed
為 null
時,改變按鈕顏色並禁用點擊。Theme.of(context)
或自定義的 ThemeExtension
來獲取設計系統中的值,這能確保你的程式碼與設計規範保持同步。將以「主要按鈕(PrimaryButton)」為例,從零開始建構一個完整的 Flutter 元件。這個元件的樣式將會與我們之前建立的設計系統(顏色、間距、圓角、字型)緊密結合。
text
:按鈕上顯示的文字。onPressed
:按鈕被點擊時執行的動作。當此屬性為 null
時,按鈕會自動變成禁用狀態。size
:按鈕的尺寸,例如 medium
和 large
。leftIcon
與 rightIcon
:可選的左右圖示,讓按鈕有更多樣的組合。AppPrimaryColors.primary100
或 AppGrayscaleColors.gray200
等靜態屬性。app_colors.dart
檔案即可。AppSpacing
類別取得間距值,例如 AppSpacing.small
來控制圖示與文字之間的距離。AppRadius
類別取得圓角值,例如 AppRadius.medium
來設定按鈕的圓角。AppTypography.textTheme
來取得預先定義好的文字樣式,例如 Theme.of(context).textTheme.bodyLarge
。AppPrimaryColors.primary100
,文字和圖示顏色為 AppGrayscaleColors.gray800
。AppGrayscaleColors.gray200
,文字和圖示顏色為 AppGrayscaleColors.gray300
。PrimaryButton(
text: '點我',
onPressed: () {
// 點擊後執行的動作
},
)
class PrimaryButton extends StatelessWidget {
// 按鈕上顯示的文字
final String text;
// 按鈕被點擊時觸發的函式
// 如果傳入 null,按鈕會呈現禁用狀態
final VoidCallback? onPressed;
// 按鈕的尺寸,使用上面定義的枚舉
final AppButtonSize size;
// 可選的左邊圖示
final IconData? leftIcon;
// 可選的右邊圖示
final IconData? rightIcon;
const PrimaryButton({
super.key,
required this.text,
this.onPressed,
this.size = AppButtonSize.medium,
this.leftIcon,
this.rightIcon,
});
@override
Widget build(BuildContext context) {
// 判斷按鈕是否為禁用狀態
final bool isDisabled = onPressed == null;
// 根據尺寸枚舉設定填充、最小高度和圖示大小
final EdgeInsets padding;
final double minHeight;
final double spacing;
final double iconSize;
switch (size) {
case AppButtonSize.medium:
padding = const EdgeInsets.symmetric(horizontal: AppSpacing.medium);
minHeight = 36.0;
spacing = AppSpacing.small;
iconSize = 20.0;
break;
case AppButtonSize.large:
padding = const EdgeInsets.symmetric(horizontal: AppSpacing.medium);
minHeight = 48.0;
spacing = AppSpacing.medium;
iconSize = 24.0;
break;
}
// 統一設定背景色與文字/圖示顏色
final Color backgroundColor =
isDisabled ? AppGrayscaleColors.gray200 : AppPrimaryColors.primary100;
final Color foregroundColor =
isDisabled ? AppGrayscaleColors.gray300 : AppGrayscaleColors.gray800;
// 定義按鈕的樣式
final ButtonStyle buttonStyle = ElevatedButton.styleFrom(
// 背景色
backgroundColor: backgroundColor,
// 文字和圖示顏色
foregroundColor: foregroundColor,
// 圓角半徑
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.medium),
),
// 內邊距
padding: padding,
// 陰影效果
elevation: 0,
// 定義按鈕的最小高度
minimumSize: Size.fromHeight(minHeight),
);
// 建立按鈕的文字和圖示
final Widget buttonContent = Row(
mainAxisSize: MainAxisSize.min, // 讓 Row 的寬度只包住內容
children: [
// 如果有左邊圖示就顯示
if (leftIcon != null) ...[
Icon(
leftIcon,
color: foregroundColor,
size: iconSize,
),
SizedBox(width: spacing), // 圖示和文字之間的間距
],
// 按鈕文字
Text(
text,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: foregroundColor,
),
),
// 如果有右邊圖示就顯示
if (rightIcon != null) ...[
SizedBox(width: spacing), // 圖示和文字之間的間距
Icon(
rightIcon,
color: foregroundColor,
size: iconSize,
),
],
],
);
return ElevatedButton(
// 只有在啟用時才會有 onPressed 效果
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: buttonContent,
);
}
}
按鈕 | 輸入框 |
---|---|
![]() |
![]() |
在 Day 6 的 PrimaryButton 實作中,我們採用了靜態顏色類別來定義樣式。然而,這種做法並非 Flutter 推薦的「主題化」最佳實踐。
為了讓設計系統更加健壯,同時能夠輕鬆支援淺色、深色等多種顏色模式,將在 Day 7 正式導入多主題設計。透過這種方式,不僅能讓 UI 具備更強的適應性,也能大幅提升程式碼的彈性和可維護性。